4me-sdk 1.2.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/4me-sdk.gemspec +12 -13
- data/Gemfile.lock +36 -14
- data/LICENSE +1 -1
- data/README.md +31 -23
- data/lib/sdk4me.rb +6 -5
- data/lib/sdk4me/ca-bundle.crt +1327 -1802
- data/lib/sdk4me/client.rb +97 -86
- data/lib/sdk4me/client/attachments.rb +107 -116
- data/lib/sdk4me/client/multipart.rb +16 -18
- data/lib/sdk4me/client/response.rb +17 -19
- data/lib/sdk4me/client/version.rb +1 -1
- data/spec/lib/sdk4me/attachments_spec.rb +282 -203
- data/spec/lib/sdk4me/certificate_spec.rb +3 -4
- data/spec/lib/sdk4me/client_spec.rb +165 -157
- data/spec/lib/sdk4me/response_spec.rb +25 -29
- data/spec/lib/sdk4me_spec.rb +7 -7
- data/spec/spec_helper.rb +5 -8
- data/spec/support/matchers/never_raise.rb +16 -21
- data/spec/support/util.rb +2 -2
- metadata +32 -18
data/lib/sdk4me/client.rb
CHANGED
@@ -13,15 +13,18 @@ require 'sdk4me/client/multipart'
|
|
13
13
|
require 'sdk4me/client/attachments'
|
14
14
|
|
15
15
|
# cherry-pick some core extensions from active support
|
16
|
-
require 'active_support/core_ext/module/aliasing
|
16
|
+
require 'active_support/core_ext/module/aliasing'
|
17
17
|
require 'active_support/core_ext/object/blank'
|
18
|
-
require 'active_support/core_ext/object/try
|
18
|
+
require 'active_support/core_ext/object/try'
|
19
19
|
require 'active_support/core_ext/hash/indifferent_access'
|
20
20
|
|
21
21
|
module Sdk4me
|
22
22
|
class Client
|
23
23
|
MAX_PAGE_SIZE = 100
|
24
|
-
DEFAULT_HEADER = {
|
24
|
+
DEFAULT_HEADER = {
|
25
|
+
'Content-Type' => 'application/json',
|
26
|
+
'User-Agent' => "4me-sdk-ruby/#{Sdk4me::Client::VERSION}"
|
27
|
+
}.freeze
|
25
28
|
|
26
29
|
# Create a new 4me SDK Client
|
27
30
|
#
|
@@ -37,13 +40,14 @@ module Sdk4me
|
|
37
40
|
#
|
38
41
|
# All options available:
|
39
42
|
# - logger: The Ruby Logger instance, default: Logger.new(STDOUT)
|
40
|
-
# - host: The 4me API host, default: 'https://api.4me.com'
|
41
|
-
# - api_version: The 4me API version, default: 'v1'
|
43
|
+
# - host: The 4me REST API host, default: 'https://api.4me.com'
|
44
|
+
# - api_version: The 4me REST API version, default: 'v1'
|
42
45
|
# - access_token: *required* The 4me access token
|
43
46
|
# - account: Specify a different (trusted) account to work with
|
44
47
|
# @see https://developer.4me.com/v1/#multiple-accounts
|
45
48
|
# - source: The Source used when creating new records
|
46
49
|
# @see https://developer.4me.com/v1/general/source/
|
50
|
+
# - user_agent: The User-Agent header of each request
|
47
51
|
#
|
48
52
|
# - max_retry_time: maximum nr of seconds to wait for server to respond (default = 5400 = 1.5 hours)
|
49
53
|
# the sleep time between retries starts at 2 seconds and doubles after each retry
|
@@ -59,16 +63,16 @@ module Sdk4me
|
|
59
63
|
# - proxy_password: Proxy password
|
60
64
|
def initialize(options = {})
|
61
65
|
@options = Sdk4me.configuration.current.merge(options)
|
62
|
-
[
|
63
|
-
raise ::Sdk4me::Exception
|
66
|
+
%i[host api_version].each do |required_option|
|
67
|
+
raise ::Sdk4me::Exception, "Missing required configuration option #{required_option}" if option(required_option).blank?
|
64
68
|
end
|
65
69
|
@logger = @options[:logger]
|
66
70
|
@ssl, @domain, @port = ssl_domain_port_path(option(:host))
|
67
71
|
unless option(:access_token).present?
|
68
72
|
if option(:api_token).blank?
|
69
|
-
raise ::Sdk4me::Exception
|
73
|
+
raise ::Sdk4me::Exception, 'Missing required configuration option access_token'
|
70
74
|
else
|
71
|
-
@logger.info('Use of api_token is deprecated,
|
75
|
+
@logger.info('DEPRECATED: Use of api_token is deprecated, switch to using access_token instead. -- https://developer.4me.com/v1/#authentication')
|
72
76
|
end
|
73
77
|
end
|
74
78
|
@ssl_verify_none = options[:ssl_verify_none]
|
@@ -84,15 +88,16 @@ module Sdk4me
|
|
84
88
|
# Returns total nr of resources yielded (for logging)
|
85
89
|
def each(path, params = {}, header = {}, &block)
|
86
90
|
# retrieve the resources using the max page size (least nr of API calls)
|
87
|
-
next_path = expand_path(path, {per_page: MAX_PAGE_SIZE, page: 1}.merge(params))
|
91
|
+
next_path = expand_path(path, { per_page: MAX_PAGE_SIZE, page: 1 }.merge(params))
|
88
92
|
size = 0
|
89
93
|
while next_path
|
90
94
|
# retrieve the records (with retry and optionally wait for rate-limit)
|
91
95
|
response = get(next_path, {}, header)
|
92
96
|
# raise exception in case the response is invalid
|
93
|
-
raise ::Sdk4me::Exception
|
97
|
+
raise ::Sdk4me::Exception, response.message unless response.valid?
|
98
|
+
|
94
99
|
# yield the resources
|
95
|
-
response.json.each
|
100
|
+
response.json.each(&block)
|
96
101
|
size += response.json.size
|
97
102
|
# go to the next page
|
98
103
|
next_path = response.pagination_relative_link(:next)
|
@@ -111,14 +116,14 @@ module Sdk4me
|
|
111
116
|
end
|
112
117
|
|
113
118
|
# send HTTPS PATCH request and return instance of Sdk4me::Response
|
114
|
-
def
|
115
|
-
_send(json_request(Net::HTTP::Patch, path, data, header))
|
119
|
+
def patch(path, data = {}, header = {})
|
120
|
+
_send(json_request(Net::HTTP::Patch, path, data, expand_header(header)))
|
116
121
|
end
|
117
|
-
|
122
|
+
alias put patch
|
118
123
|
|
119
124
|
# send HTTPS POST request and return instance of Sdk4me::Response
|
120
125
|
def post(path, data = {}, header = {})
|
121
|
-
_send(json_request(Net::HTTP::Post, path, data, header))
|
126
|
+
_send(json_request(Net::HTTP::Post, path, data, expand_header(header)))
|
122
127
|
end
|
123
128
|
|
124
129
|
# upload a CSV file to import
|
@@ -135,17 +140,19 @@ module Sdk4me
|
|
135
140
|
@logger.info { "Import file '#{csv.path}' successfully uploaded with token '#{response[:token]}'." } if response.valid?
|
136
141
|
|
137
142
|
if block_until_completed
|
138
|
-
raise ::Sdk4me::UploadFailed
|
143
|
+
raise ::Sdk4me::UploadFailed, "Failed to queue #{type} import. #{response.message}" unless response.valid?
|
144
|
+
|
139
145
|
token = response[:token]
|
140
|
-
|
146
|
+
loop do
|
141
147
|
response = get("/import/#{token}")
|
142
148
|
unless response.valid?
|
143
149
|
sleep(5)
|
144
150
|
response = get("/import/#{token}") # single retry to recover from a network error
|
145
|
-
raise ::Sdk4me::Exception
|
151
|
+
raise ::Sdk4me::Exception, "Unable to monitor progress for #{type} import. #{response.message}" unless response.valid?
|
146
152
|
end
|
147
153
|
# wait 30 seconds while the response is OK and import is still busy
|
148
|
-
break unless [
|
154
|
+
break unless %w[queued processing].include?(response[:state])
|
155
|
+
|
149
156
|
@logger.debug { "Import of '#{csv.path}' is #{response[:state]}. Checking again in 30 seconds." }
|
150
157
|
sleep(30)
|
151
158
|
end
|
@@ -161,7 +168,7 @@ module Sdk4me
|
|
161
168
|
# @param locale: Required for translations export
|
162
169
|
# @raise Sdk4me::Exception in case the export progress could not be monitored
|
163
170
|
def export(types, from = nil, block_until_completed = false, locale = nil)
|
164
|
-
data = {type: [types].flatten.join(',')}
|
171
|
+
data = { type: [types].flatten.join(',') }
|
165
172
|
data[:from] = from unless from.blank?
|
166
173
|
data[:locale] = locale unless locale.blank?
|
167
174
|
response = post('/export', data)
|
@@ -174,17 +181,19 @@ module Sdk4me
|
|
174
181
|
end
|
175
182
|
|
176
183
|
if block_until_completed
|
177
|
-
raise ::Sdk4me::UploadFailed
|
184
|
+
raise ::Sdk4me::UploadFailed, "Failed to queue '#{data[:type]}' export. #{response.message}" unless response.valid?
|
185
|
+
|
178
186
|
token = response[:token]
|
179
|
-
|
187
|
+
loop do
|
180
188
|
response = get("/export/#{token}")
|
181
189
|
unless response.valid?
|
182
190
|
sleep(5)
|
183
191
|
response = get("/export/#{token}") # single retry to recover from a network error
|
184
|
-
raise ::Sdk4me::Exception
|
192
|
+
raise ::Sdk4me::Exception, "Unable to monitor progress for '#{data[:type]}' export. #{response.message}" unless response.valid?
|
185
193
|
end
|
186
194
|
# wait 30 seconds while the response is OK and export is still busy
|
187
|
-
break unless [
|
195
|
+
break unless %w[queued processing].include?(response[:state])
|
196
|
+
|
188
197
|
@logger.debug { "Export of '#{data[:type]}' is #{response[:state]}. Checking again in 30 seconds." }
|
189
198
|
sleep(30)
|
190
199
|
end
|
@@ -193,41 +202,37 @@ module Sdk4me
|
|
193
202
|
response
|
194
203
|
end
|
195
204
|
|
196
|
-
|
197
|
-
@logger
|
198
|
-
end
|
205
|
+
attr_reader :logger
|
199
206
|
|
200
207
|
private
|
201
208
|
|
202
209
|
# create a request (place data in body if the request becomes too large)
|
203
|
-
def json_request(request_class, path, data
|
204
|
-
Sdk4me::Attachments.new(self).upload_attachments!(
|
205
|
-
request = request_class.new(expand_path(path),
|
210
|
+
def json_request(request_class, path, data, header)
|
211
|
+
Sdk4me::Attachments.new(self, path).upload_attachments!(data)
|
212
|
+
request = request_class.new(expand_path(path), header)
|
206
213
|
body = {}
|
207
|
-
data.each{ |k,v| body[k.to_s] = typecast(v, false) }
|
214
|
+
data.each { |k, v| body[k.to_s] = typecast(v, false) }
|
208
215
|
request.body = body.to_json
|
209
216
|
request
|
210
217
|
end
|
211
218
|
|
212
|
-
URI_ESCAPE_PATTERN = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
|
213
219
|
def uri_escape(value)
|
214
|
-
URI.
|
220
|
+
URI.encode_www_form_component(value).gsub('+', '%20').gsub('.', '%2E')
|
215
221
|
end
|
216
222
|
|
217
223
|
# Expand the given header with the default header
|
218
|
-
def expand_header(
|
219
|
-
header = DEFAULT_HEADER.
|
224
|
+
def expand_header(headers = {})
|
225
|
+
header = DEFAULT_HEADER.dup
|
220
226
|
header['X-4me-Account'] = option(:account) if option(:account)
|
221
227
|
if option(:access_token).present?
|
222
|
-
header['AUTHORIZATION'] =
|
228
|
+
header['AUTHORIZATION'] = "Bearer #{option(:access_token)}"
|
223
229
|
else
|
224
230
|
token_and_password = option(:api_token).include?(':') ? option(:api_token) : "#{option(:api_token)}:x"
|
225
|
-
header['AUTHORIZATION'] =
|
226
|
-
end
|
227
|
-
if option(:source)
|
228
|
-
header['X-4me-Source'] = option(:source)
|
229
|
-
header['HTTP_USER_AGENT'] = option(:source)
|
231
|
+
header['AUTHORIZATION'] = "Basic #{[token_and_password].pack('m*').gsub(/\s/, '')}"
|
230
232
|
end
|
233
|
+
header['X-4me-Source'] = option(:source) if option(:source)
|
234
|
+
header['User-Agent'] = option(:user_agent) if option(:user_agent)
|
235
|
+
header.merge!(headers)
|
231
236
|
header
|
232
237
|
end
|
233
238
|
|
@@ -238,8 +243,8 @@ module Sdk4me
|
|
238
243
|
# fields: ['id', 'created_at', 'sourceID']
|
239
244
|
def expand_path(path, params = {})
|
240
245
|
path = path.dup
|
241
|
-
path = "/#{path}" unless path =~
|
242
|
-
path = "/#{option(:api_version)}#{path}" unless path =~
|
246
|
+
path = "/#{path}" unless path =~ %r{^/} # make sure path starts with /
|
247
|
+
path = "/#{option(:api_version)}#{path}" unless path =~ %r{^/v[\d.]+/} # preprend api version
|
243
248
|
params.each do |key, value|
|
244
249
|
path << (path['?'] ? '&' : '?')
|
245
250
|
path << expand_param(key, value)
|
@@ -258,18 +263,20 @@ module Sdk4me
|
|
258
263
|
# Parameter value typecasting
|
259
264
|
def typecast(value, escape = true)
|
260
265
|
case value.class.name.to_sym
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
266
|
+
when :NilClass then ''
|
267
|
+
when :String then escape ? uri_escape(value) : value
|
268
|
+
when :TrueClass then 'true'
|
269
|
+
when :FalseClass then 'false'
|
270
|
+
when :DateTime
|
271
|
+
datetime = value.new_offset(0).iso8601
|
272
|
+
escape ? uri_escape(datetime) : datetime
|
273
|
+
when :Date then value.strftime('%Y-%m-%d')
|
274
|
+
when :Time then value.strftime('%H:%M')
|
268
275
|
# do not convert arrays in put/post requests as squashing arrays is only used in filtering
|
269
|
-
|
276
|
+
when :Array then escape ? value.map { |v| typecast(v, escape) }.join(',') : value
|
270
277
|
# TODO: temporary for special constructions to update contact details, see Request #1444166
|
271
|
-
|
272
|
-
|
278
|
+
when :Hash then escape ? value.to_s : value
|
279
|
+
else escape ? value.to_json : value.to_s
|
273
280
|
end
|
274
281
|
end
|
275
282
|
|
@@ -277,27 +284,27 @@ module Sdk4me
|
|
277
284
|
# Guaranteed to return a Response, thought it may be +empty?+
|
278
285
|
def _send(request, domain = @domain, port = @port, ssl = @ssl)
|
279
286
|
@logger.debug { "Sending #{request.method} request to #{domain}:#{port}#{request.path}" }
|
280
|
-
|
287
|
+
response = begin
|
281
288
|
http_with_proxy = option(:proxy_host).blank? ? Net::HTTP : Net::HTTP::Proxy(option(:proxy_host), option(:proxy_port), option(:proxy_user), option(:proxy_password))
|
282
289
|
http = http_with_proxy.new(domain, port)
|
283
290
|
http.read_timeout = option(:read_timeout)
|
284
291
|
http.use_ssl = ssl
|
285
292
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl_verify_none
|
286
|
-
http.start{ |
|
287
|
-
rescue
|
293
|
+
http.start { |transport| transport.request(request) }
|
294
|
+
rescue StandardError => e
|
288
295
|
Struct.new(:body, :message, :code, :header).new(nil, "No Response from Server - #{e.message} for '#{domain}:#{port}#{request.path}'", 500, {})
|
289
296
|
end
|
290
|
-
|
291
|
-
if
|
292
|
-
@logger.debug { "Response:\n#{JSON.pretty_generate(
|
293
|
-
elsif
|
294
|
-
@logger.debug { "XML response:\n#{
|
295
|
-
elsif
|
296
|
-
@logger.debug { "Redirect: #{
|
297
|
+
resp = Sdk4me::Response.new(request, response)
|
298
|
+
if resp.valid?
|
299
|
+
@logger.debug { "Response:\n#{JSON.pretty_generate(resp.json)}" }
|
300
|
+
elsif resp.raw.body =~ /^\s*<\?xml/i
|
301
|
+
@logger.debug { "XML response:\n#{resp.raw.body}" }
|
302
|
+
elsif resp.raw.code.to_s == '303'
|
303
|
+
@logger.debug { "Redirect: #{resp.raw.header['Location']}" }
|
297
304
|
else
|
298
|
-
@logger.error { "#{request.method} request to #{domain}:#{port}#{request.path} failed: #{
|
305
|
+
@logger.error { "#{request.method} request to #{domain}:#{port}#{request.path} failed: #{resp.message}" }
|
299
306
|
end
|
300
|
-
|
307
|
+
resp
|
301
308
|
end
|
302
309
|
|
303
310
|
# parse the given URI to [domain, port, ssl, path]
|
@@ -306,65 +313,70 @@ module Sdk4me
|
|
306
313
|
ssl = uri.scheme == 'https'
|
307
314
|
[ssl, uri.host, uri.port, uri.path]
|
308
315
|
end
|
309
|
-
|
310
316
|
end
|
311
317
|
|
312
318
|
module SendWithRateLimitBlock
|
313
319
|
# Wraps the _send method with retries when the server does not respond, see +initialize+ option +:rate_limit_block+
|
314
320
|
def _send(request, domain = @domain, port = @port, ssl = @ssl)
|
315
|
-
return super(request, domain, port, ssl) unless option(:block_at_rate_limit) && option(:max_throttle_time)
|
321
|
+
return super(request, domain, port, ssl) unless option(:block_at_rate_limit) && option(:max_throttle_time).positive?
|
322
|
+
|
316
323
|
now = nil
|
317
324
|
timed_out = false
|
318
|
-
|
319
|
-
|
325
|
+
response = nil
|
326
|
+
loop do
|
327
|
+
response = super(request, domain, port, ssl)
|
320
328
|
now ||= Time.now
|
321
|
-
if
|
329
|
+
if response.throttled?
|
322
330
|
# if no Retry-After is not provided, the 4me server is very busy, wait 5 minutes
|
323
|
-
retry_after =
|
331
|
+
retry_after = response.retry_after.zero? ? 300 : [response.retry_after, 2].max
|
324
332
|
if (Time.now - now + retry_after) < option(:max_throttle_time)
|
325
|
-
@logger.warn { "Request throttled, trying again in #{retry_after} seconds: #{
|
333
|
+
@logger.warn { "Request throttled, trying again in #{retry_after} seconds: #{response.message}" }
|
326
334
|
sleep(retry_after)
|
327
335
|
else
|
328
336
|
timed_out = true
|
329
337
|
end
|
330
338
|
end
|
331
|
-
|
332
|
-
|
339
|
+
break unless response.throttled? && !timed_out
|
340
|
+
end
|
341
|
+
response
|
333
342
|
end
|
334
343
|
end
|
335
|
-
Client.
|
344
|
+
Client.prepend SendWithRateLimitBlock
|
336
345
|
|
337
346
|
module SendWithRetries
|
338
347
|
# Wraps the _send method with retries when the server does not respond, see +initialize+ option +:retries+
|
339
348
|
def _send(request, domain = @domain, port = @port, ssl = @ssl)
|
340
|
-
return super(request, domain, port, ssl) unless option(:max_retry_time)
|
349
|
+
return super(request, domain, port, ssl) unless option(:max_retry_time).positive?
|
350
|
+
|
341
351
|
retries = 0
|
342
352
|
sleep_time = 1
|
343
353
|
now = nil
|
344
354
|
timed_out = false
|
345
|
-
|
346
|
-
|
355
|
+
response = nil
|
356
|
+
loop do
|
357
|
+
response = super(request, domain, port, ssl)
|
347
358
|
now ||= Time.now
|
348
|
-
if
|
359
|
+
if response.failure?
|
349
360
|
sleep_time *= 2
|
350
361
|
if (Time.now - now + sleep_time) < option(:max_retry_time)
|
351
|
-
@logger.warn { "Request failed, retry ##{retries += 1} in #{sleep_time} seconds: #{
|
362
|
+
@logger.warn { "Request failed, retry ##{retries += 1} in #{sleep_time} seconds: #{response.message}" }
|
352
363
|
sleep(sleep_time)
|
353
364
|
else
|
354
365
|
timed_out = true
|
355
366
|
end
|
356
367
|
end
|
357
|
-
|
358
|
-
|
368
|
+
break unless response.failure? && !timed_out
|
369
|
+
end
|
370
|
+
response
|
359
371
|
end
|
360
372
|
end
|
361
|
-
Client.
|
373
|
+
Client.prepend SendWithRetries
|
362
374
|
end
|
363
375
|
|
364
376
|
# HTTPS with certificate bundle
|
365
377
|
module Net
|
366
378
|
class HTTP
|
367
|
-
|
379
|
+
alias original_use_ssl= use_ssl=
|
368
380
|
|
369
381
|
def use_ssl=(flag)
|
370
382
|
self.ca_file = File.expand_path(Sdk4me.configuration.current[:ca_file], __FILE__) if flag
|
@@ -373,4 +385,3 @@ module Net
|
|
373
385
|
end
|
374
386
|
end
|
375
387
|
end
|
376
|
-
|
@@ -1,152 +1,143 @@
|
|
1
1
|
module Sdk4me
|
2
2
|
class Attachments
|
3
|
+
S3_PROVIDER = 's3'.freeze
|
4
|
+
FILENAME_TEMPLATE = '${filename}'.freeze
|
3
5
|
|
4
|
-
|
5
|
-
FILENAME_TEMPLATE = '${filename}'
|
6
|
-
|
7
|
-
def initialize(client)
|
6
|
+
def initialize(client, path)
|
8
7
|
@client = client
|
8
|
+
@path = path
|
9
9
|
end
|
10
10
|
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
data
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
#
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
11
|
+
# Upload attachments and replace the data inline with the uploaded
|
12
|
+
# attachments info.
|
13
|
+
#
|
14
|
+
# To upload field attachments:
|
15
|
+
# * data[:note_attachments] = ['/tmp/test.doc', '/tmp/test.log']
|
16
|
+
#
|
17
|
+
# To upload inline images:
|
18
|
+
# * data[:note] containing text referring to inline images in
|
19
|
+
# data[:note_attachments] by their array index, with the index being
|
20
|
+
# zero-based. Text can only refer to inline images in its own
|
21
|
+
# attachments collection. For example:
|
22
|
+
#
|
23
|
+
# data = {
|
24
|
+
# note: "Hello [note_attachments: 0] and [note_attachments: 1]",
|
25
|
+
# note_attachments: ['/tmp/jip.png', '/tmp/janneke.png'],
|
26
|
+
# ...
|
27
|
+
# }
|
28
|
+
#
|
29
|
+
# After calling this method the data that will be posted to update the
|
30
|
+
# 4me record would look similar to:
|
31
|
+
#
|
32
|
+
# data = {
|
33
|
+
# note: "Hello  and ",
|
34
|
+
# note_attachments: [
|
35
|
+
# { key: 'storage/abc/adjhajdhjaadf.png', filesize: 12345, inline: true },
|
36
|
+
# { key: 'storage/abc/fskdhakjfkjdssdf.png'], filesize: 98765, inline: true }
|
37
|
+
# ],
|
38
|
+
# ...
|
39
|
+
# }
|
40
|
+
def upload_attachments!(data)
|
41
|
+
# Field attachments
|
42
|
+
field_attachments = []
|
43
|
+
data.each do |field, value|
|
44
|
+
next unless field.to_s.end_with?('_attachments')
|
45
|
+
next unless value.is_a?(Enumerable) && value.any?
|
46
|
+
|
47
|
+
value.map! { |attachment| upload_attachment(attachment) }.compact!
|
48
|
+
field_attachments << field if value.any?
|
38
49
|
end
|
39
|
-
end
|
40
50
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
else
|
57
|
-
full_match
|
51
|
+
# Rich text inline attachments
|
52
|
+
field_attachments.each do |field_attachment|
|
53
|
+
field = field_attachment.to_s.sub(/_attachments$/, '')
|
54
|
+
value = data[field.to_sym] || data[field]
|
55
|
+
next unless value.is_a?(String)
|
56
|
+
|
57
|
+
value.gsub!(/\[#{field_attachment}:\s?(\d+)\]/) do |match|
|
58
|
+
idx = Regexp.last_match(1).to_i
|
59
|
+
attachment = data[field_attachment][idx]
|
60
|
+
if attachment
|
61
|
+
attachment[:inline] = true
|
62
|
+
"" # magic markdown for inline attachments
|
63
|
+
else
|
64
|
+
match
|
65
|
+
end
|
58
66
|
end
|
59
67
|
end
|
60
|
-
attachments
|
61
68
|
end
|
62
69
|
|
63
|
-
|
64
|
-
# retrieve the upload configuration for this record from 4me
|
65
|
-
storage = @client.get(path =~ /\d+$/ ? path : "#{path}/new", {attachment_upload_token: true}, @client.send(:expand_header))[:storage_upload]
|
66
|
-
report_error("Attachments not allowed for #{path}", raise_exceptions) unless storage
|
67
|
-
storage
|
68
|
-
end
|
70
|
+
private
|
69
71
|
|
70
|
-
def
|
71
|
-
|
72
|
-
|
73
|
-
:remarks_attachments
|
74
|
-
when /service_offerings/
|
75
|
-
:summary_attachments
|
76
|
-
else
|
77
|
-
:note_attachments
|
78
|
-
end
|
72
|
+
def raise_error(message)
|
73
|
+
@client.logger.error { message }
|
74
|
+
raise Sdk4me::UploadFailed, message
|
79
75
|
end
|
80
76
|
|
81
|
-
def
|
82
|
-
|
83
|
-
raise Sdk4me::UploadFailed.new(message)
|
84
|
-
else
|
85
|
-
@client.logger.error{ message }
|
86
|
-
end
|
77
|
+
def storage
|
78
|
+
@storage ||= @client.get('/attachments/storage').json.with_indifferent_access
|
87
79
|
end
|
88
80
|
|
89
|
-
#
|
90
|
-
#
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
end
|
81
|
+
# Upload a single attachment and return the data that should be submitted
|
82
|
+
# back to 4me. Returns nil and provides an error in case the attachment
|
83
|
+
# upload failed.
|
84
|
+
def upload_attachment(attachment)
|
85
|
+
return nil unless attachment
|
86
|
+
|
87
|
+
provider = storage[:provider]
|
88
|
+
raise 'No provider found' unless provider
|
98
89
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
# return the values for the note_attachments param
|
106
|
-
{key: key, filesize: File.size(attachment.path)}
|
107
|
-
rescue ::Exception => e
|
108
|
-
report_error("Attachment upload failed: #{e.message}", raise_exceptions)
|
109
|
-
nil
|
90
|
+
# attachment is already a file or we need to open the file from disk
|
91
|
+
unless attachment.respond_to?(:path) && attachment.respond_to?(:read)
|
92
|
+
raise "file does not exist: #{attachment}" unless File.exist?(attachment)
|
93
|
+
|
94
|
+
attachment = File.open(attachment, 'rb')
|
110
95
|
end
|
96
|
+
|
97
|
+
key_template = storage[provider][:key]
|
98
|
+
key = key_template.sub(FILENAME_TEMPLATE, File.basename(attachment.path))
|
99
|
+
|
100
|
+
key = if provider == S3_PROVIDER
|
101
|
+
upload_to_s3(key, attachment)
|
102
|
+
else
|
103
|
+
upload_to_4me_local(key, attachment)
|
104
|
+
end
|
105
|
+
|
106
|
+
# return the values for the attachments param
|
107
|
+
{ key: key, filesize: File.size(attachment.path) }
|
108
|
+
rescue StandardError => e
|
109
|
+
raise_error("Attachment upload failed: #{e.message}")
|
111
110
|
end
|
112
111
|
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
AWSAccessKeyId: aws[:access_key],
|
119
|
-
acl: 'private',
|
120
|
-
signature: aws[:signature],
|
121
|
-
success_action_status: 201,
|
122
|
-
policy: aws[:policy],
|
123
|
-
file: attachment # file must be last
|
124
|
-
})
|
112
|
+
# Upload the file to AWS S3 storage
|
113
|
+
def upload_to_s3(key, attachment)
|
114
|
+
uri = storage[:upload_uri]
|
115
|
+
response = send_file(uri, storage[:s3].merge({ file: attachment }))
|
116
|
+
|
125
117
|
# this is a bit of a hack, but Amazon S3 returns only XML :(
|
126
|
-
xml = response.
|
127
|
-
error = xml[
|
128
|
-
raise "AWS upload to #{
|
118
|
+
xml = response.body || ''
|
119
|
+
error = xml[%r{<Error>.*<Message>(.*)</Message>.*</Error>}, 1]
|
120
|
+
raise "AWS S3 upload to #{uri} for #{key} failed: #{error}" if error
|
129
121
|
|
130
|
-
|
131
|
-
response = @client.get(aws[:success_url].split('/').last, {key: key}, @client.send(:expand_header))
|
132
|
-
raise "4me confirmation #{aws[:success_url].split('/').last} for #{key} failed: #{response.message}" unless response.valid?
|
122
|
+
xml[%r{<Key>(.*)</Key>}, 1]
|
133
123
|
end
|
134
124
|
|
135
|
-
#
|
136
|
-
def
|
137
|
-
uri = storage[:upload_uri]
|
138
|
-
response = send_file(uri, {file: attachment
|
139
|
-
raise "4me upload to #{
|
125
|
+
# Upload the file directly to 4me local storage
|
126
|
+
def upload_to_4me_local(key, attachment)
|
127
|
+
uri = storage[:upload_uri]
|
128
|
+
response = send_file(uri, storage[:local].merge({ file: attachment }), @client.send(:expand_header))
|
129
|
+
raise "4me upload to #{uri} for #{key} failed: #{response.message}" unless response.valid?
|
130
|
+
|
131
|
+
JSON.parse(response.body)['key']
|
140
132
|
end
|
141
133
|
|
142
134
|
def send_file(uri, params, basic_auth_header = {})
|
143
|
-
params = {
|
135
|
+
params = { 'Content-Type': MIME::Types.type_for(params[:key])[0] || MIME::Types['application/octet-stream'][0] }.merge(params)
|
144
136
|
data, header = Sdk4me::Multipart::Post.prepare_query(params)
|
145
137
|
ssl, domain, port, path = @client.send(:ssl_domain_port_path, uri)
|
146
138
|
request = Net::HTTP::Post.new(path, basic_auth_header.merge(header))
|
147
139
|
request.body = data
|
148
140
|
@client.send(:_send, request, domain, port, ssl)
|
149
141
|
end
|
150
|
-
|
151
142
|
end
|
152
143
|
end
|